iT邦幫忙

2025 iThome 鐵人賽

DAY 26
0
Modern Web

30 天製作工作室 SaaS 產品 (前端篇)系列 第 26

Day 26: 30天打造SaaS產品前端篇-多因素認證 (MFA) 與帳號安全強化

  • 分享至 

  • xImage
  •  

前情提要

經過 Day 25 的用戶認證系統建置,我們已經有了完整的登入/註冊機制。今天我們要為 Kyo System 增加企業級的多因素認證 (MFA) 與帳號安全強化功能。在現代 SaaS 產品中,單純的密碼認證早就不夠安全,我們需要 MFA、裝置信任管理、登入歷史追蹤等多層防護,來保護用戶的敏感資料。

MFA 架構概覽

/**
 * 多因素認證 (MFA) 完整架構
 *
 * ┌─────────────────────────────────────────────┐
 * │         MFA Authentication Flow             │
 * └─────────────────────────────────────────────┘
 *
 * 第一階段:密碼認證
 *    ↓
 * 第二階段:選擇 MFA 方式
 *    ├─ TOTP (Time-based OTP)
 *    │  └─ Google Authenticator / Authy
 *    ├─ SMS OTP
 *    │  └─ 手機簡訊驗證碼
 *    └─ 備份碼
 *       └─ 一次性恢復碼
 *    ↓
 * 驗證成功 → 建立會話
 *    ├─ 記住此裝置(30天免 MFA)
 *    └─ 記錄登入歷史
 *
 * MFA 設定流程:
 * 1. 掃描 QR Code
 * 2. 輸入驗證碼確認
 * 3. 保存備份碼
 * 4. MFA 啟用
 *
 * 安全特性:
 * ✅ TOTP 基於 RFC 6238 標準
 * ✅ 備份碼加密儲存
 * ✅ 裝置指紋識別
 * ✅ 異常登入偵測
 * ✅ 登入歷史審計
 * ✅ 會話管理
 */

TOTP 設定元件

// src/pages/Security/MFASetup.tsx
import { useState, useEffect } from 'react';
import {
  Container,
  Paper,
  Title,
  Text,
  Stepper,
  Button,
  Group,
  Stack,
  PinInput,
  Alert,
  Code,
  CopyButton,
  ActionIcon,
  Tooltip,
  Center,
  Box,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import {
  IconShield,
  IconCheck,
  IconCopy,
  IconAlertTriangle,
  IconDownload,
} from '@tabler/icons-react';
import { QRCodeSVG } from 'qrcode.react';
import { authenticator } from 'otpauth';
import { useAuth } from '../../contexts/AuthContext';
import { notifications } from '@mantine/notifications';

interface MFASetupData {
  secret: string;
  qrCodeUrl: string;
  backupCodes: string[];
}

export function MFASetupPage() {
  const { user } = useAuth();
  const [active, setActive] = useState(0);
  const [loading, setLoading] = useState(false);
  const [mfaData, setMfaData] = useState<MFASetupData | null>(null);
  const [verificationCode, setVerificationCode] = useState('');

  /**
   * 步驟 1: 生成 TOTP Secret 與 QR Code
   */
  const initializeMFA = async () => {
    try {
      setLoading(true);

      // 呼叫 API 生成 MFA Secret
      const response = await fetch('/api/auth/mfa/initialize', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
          'Content-Type': 'application/json',
        },
      });

      const data = await response.json();

      setMfaData({
        secret: data.secret,
        qrCodeUrl: data.qrCodeUrl,
        backupCodes: data.backupCodes,
      });

      setActive(1);
    } catch (error: any) {
      notifications.show({
        title: '初始化失敗',
        message: error.message || '無法生成 MFA 設定',
        color: 'red',
      });
    } finally {
      setLoading(false);
    }
  };

  /**
   * 步驟 2: 驗證 TOTP 碼
   */
  const verifyTOTP = async () => {
    if (verificationCode.length !== 6) {
      notifications.show({
        title: '驗證碼錯誤',
        message: '請輸入 6 位數驗證碼',
        color: 'red',
      });
      return;
    }

    try {
      setLoading(true);

      const response = await fetch('/api/auth/mfa/verify', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          code: verificationCode,
          secret: mfaData?.secret,
        }),
      });

      if (!response.ok) {
        throw new Error('驗證碼錯誤');
      }

      notifications.show({
        title: '驗證成功',
        message: 'TOTP 驗證成功',
        color: 'green',
      });

      setActive(2);
    } catch (error: any) {
      notifications.show({
        title: '驗證失敗',
        message: error.message || '驗證碼錯誤,請重試',
        color: 'red',
      });
    } finally {
      setLoading(false);
    }
  };

  /**
   * 步驟 3: 啟用 MFA
   */
  const enableMFA = async () => {
    try {
      setLoading(true);

      const response = await fetch('/api/auth/mfa/enable', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          secret: mfaData?.secret,
        }),
      });

      if (!response.ok) {
        throw new Error('啟用失敗');
      }

      notifications.show({
        title: 'MFA 已啟用',
        message: '您的帳號已受到雙因素驗證保護',
        color: 'green',
        icon: <IconShield />,
      });

      setActive(3);
    } catch (error: any) {
      notifications.show({
        title: '啟用失敗',
        message: error.message || '無法啟用 MFA',
        color: 'red',
      });
    } finally {
      setLoading(false);
    }
  };

  /**
   * 下載備份碼
   */
  const downloadBackupCodes = () => {
    if (!mfaData?.backupCodes) return;

    const content = `Kyo System - MFA 備份碼\n\n` +
      `帳號: ${user?.email}\n` +
      `生成時間: ${new Date().toLocaleString()}\n\n` +
      `備份碼(每個只能使用一次):\n` +
      mfaData.backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n') +
      `\n\n請妥善保管此文件,不要分享給任何人。`;

    const blob = new Blob([content], { type: 'text/plain' });
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = url;
    link.download = `kyo-mfa-backup-codes-${Date.now()}.txt`;
    link.click();
    URL.revokeObjectURL(url);

    notifications.show({
      title: '備份碼已下載',
      message: '請妥善保管此文件',
      color: 'blue',
    });
  };

  return (
    <Container size={600} my={40}>
      <Paper radius="md" p="xl" withBorder>
        <Title order={2} mb="xl">
          設定雙因素驗證 (MFA)
        </Title>

        <Stepper active={active} onStepClick={setActive} breakpoint="sm">
          {/* 步驟 1: 介紹 */}
          <Stepper.Step label="開始設定" description="了解 MFA">
            <Stack spacing="md" py="xl">
              <Alert icon={<IconShield size={16} />} title="為什麼需要 MFA?" color="blue">
                雙因素驗證能在密碼外加上額外的安全層,即使密碼被盜,攻擊者仍無法登入您的帳號。
              </Alert>

              <Text>
                <strong>MFA 如何運作?</strong>
              </Text>
              <Text size="sm" color="dimmed">
                1. 使用 Google Authenticator 或 Authy 等驗證應用程式
                <br />
                2. 掃描 QR Code 綁定您的帳號
                <br />
                3. 登入時輸入驗證應用程式產生的 6 位數驗證碼
                <br />
                4. 每 30 秒驗證碼會自動更新
              </Text>

              <Button
                onClick={initializeMFA}
                loading={loading}
                leftIcon={<IconShield size={18} />}
              >
                開始設定 MFA
              </Button>
            </Stack>
          </Stepper.Step>

          {/* 步驟 2: 掃描 QR Code */}
          <Stepper.Step label="掃描 QR Code" description="使用驗證應用程式">
            {mfaData && (
              <Stack spacing="md" py="xl">
                <Text>
                  使用 <strong>Google Authenticator</strong> 或 <strong>Authy</strong>
                  掃描此 QR Code
                </Text>

                <Center>
                  <Box p="md" style={{ background: 'white', borderRadius: 8 }}>
                    <QRCodeSVG
                      value={mfaData.qrCodeUrl}
                      size={200}
                      level="H"
                      includeMargin
                    />
                  </Box>
                </Center>

                <Alert icon={<IconAlertTriangle size={16} />} color="yellow">
                  無法掃描?手動輸入此金鑰:
                  <Code block mt="xs">
                    {mfaData.secret}
                  </Code>
                  <Group position="right" mt="xs">
                    <CopyButton value={mfaData.secret}>
                      {({ copied, copy }) => (
                        <Tooltip label={copied ? '已複製' : '複製'}>
                          <ActionIcon onClick={copy} variant="subtle">
                            {copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
                          </ActionIcon>
                        </Tooltip>
                      )}
                    </CopyButton>
                  </Group>
                </Alert>

                <Text>掃描完成後,輸入驗證應用程式顯示的 6 位數驗證碼:</Text>

                <Center>
                  <PinInput
                    length={6}
                    type="number"
                    value={verificationCode}
                    onChange={setVerificationCode}
                    size="lg"
                    placeholder=""
                  />
                </Center>

                <Button
                  onClick={verifyTOTP}
                  loading={loading}
                  disabled={verificationCode.length !== 6}
                  fullWidth
                >
                  驗證並繼續
                </Button>
              </Stack>
            )}
          </Stepper.Step>

          {/* 步驟 3: 保存備份碼 */}
          <Stepper.Step label="保存備份碼" description="重要!">
            {mfaData && (
              <Stack spacing="md" py="xl">
                <Alert icon={<IconAlertTriangle size={16} />} color="orange" title="重要!">
                  以下備份碼用於在無法使用驗證應用程式時恢復存取。
                  每個備份碼只能使用一次,請妥善保管。
                </Alert>

                <Paper p="md" withBorder>
                  <Stack spacing="xs">
                    {mfaData.backupCodes.map((code, index) => (
                      <Group key={index} position="apart">
                        <Code>{code}</Code>
                        <CopyButton value={code}>
                          {({ copied, copy }) => (
                            <Tooltip label={copied ? '已複製' : '複製'}>
                              <ActionIcon onClick={copy} variant="subtle" size="sm">
                                {copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
                              </ActionIcon>
                            </Tooltip>
                          )}
                        </CopyButton>
                      </Group>
                    ))}
                  </Stack>
                </Paper>

                <Button
                  variant="outline"
                  leftIcon={<IconDownload size={18} />}
                  onClick={downloadBackupCodes}
                >
                  下載備份碼
                </Button>

                <Button onClick={enableMFA} loading={loading} fullWidth>
                  我已保存備份碼,啟用 MFA
                </Button>
              </Stack>
            )}
          </Stepper.Step>

          {/* 步驟 4: 完成 */}
          <Stepper.Completed>
            <Stack spacing="md" py="xl" align="center">
              <IconShield size={64} color="green" />
              <Title order={3}>MFA 已成功啟用!</Title>
              <Text color="dimmed" ta="center">
                您的帳號現在受到雙因素驗證保護。
                下次登入時,您需要輸入驗證應用程式產生的驗證碼。
              </Text>
              <Button onClick={() => window.location.href = '/dashboard'}>
                返回 Dashboard
              </Button>
            </Stack>
          </Stepper.Completed>
        </Stepper>
      </Paper>
    </Container>
  );
}

MFA 登入驗證元件

// src/pages/Auth/MFAVerifyPage.tsx
import { useState } from 'react';
import {
  Container,
  Paper,
  Title,
  Text,
  Stack,
  PinInput,
  Button,
  Alert,
  Anchor,
  Group,
  Checkbox,
  Center,
} from '@mantine/core';
import { IconShield, IconAlertCircle } from '@tabler/icons-react';
import { useNavigate, useLocation } from 'react-router-dom';
import { notifications } from '@mantine/notifications';

export function MFAVerifyPage() {
  const navigate = useNavigate();
  const location = useLocation();
  const [code, setCode] = useState('');
  const [loading, setLoading] = useState(false);
  const [trustDevice, setTrustDevice] = useState(false);
  const [showBackupCode, setShowBackupCode] = useState(false);

  // 從前一頁取得暫時 token
  const tempToken = location.state?.tempToken;

  const handleVerify = async () => {
    if (code.length !== 6) {
      notifications.show({
        title: '驗證碼錯誤',
        message: '請輸入 6 位數驗證碼',
        color: 'red',
      });
      return;
    }

    try {
      setLoading(true);

      const response = await fetch('/api/auth/mfa/validate', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          tempToken,
          code,
          trustDevice,
          isBackupCode: showBackupCode,
        }),
      });

      if (!response.ok) {
        throw new Error('驗證碼錯誤');
      }

      const data = await response.json();

      // 儲存 Access Token
      localStorage.setItem('accessToken', data.accessToken);

      // 如果選擇信任此裝置,儲存裝置 token
      if (trustDevice && data.deviceToken) {
        localStorage.setItem('deviceToken', data.deviceToken);
      }

      notifications.show({
        title: '登入成功',
        message: '歡迎回來!',
        color: 'green',
      });

      navigate('/dashboard');
    } catch (error: any) {
      notifications.show({
        title: '驗證失敗',
        message: error.message || '驗證碼錯誤,請重試',
        color: 'red',
      });
      setCode('');
    } finally {
      setLoading(false);
    }
  };

  return (
    <Container size={420} my={80}>
      <Paper radius="md" p="xl" withBorder>
        <Center mb="xl">
          <IconShield size={48} color="blue" />
        </Center>

        <Title order={2} ta="center" mb="md">
          雙因素驗證
        </Title>

        <Text c="dimmed" size="sm" ta="center" mb="xl">
          {showBackupCode
            ? '輸入您的備份碼'
            : '請輸入驗證應用程式顯示的 6 位數驗證碼'
          }
        </Text>

        <Stack spacing="md">
          <Center>
            <PinInput
              length={showBackupCode ? 8 : 6}
              type={showBackupCode ? 'text' : 'number'}
              value={code}
              onChange={setCode}
              size="lg"
              placeholder=""
              oneTimeCode
            />
          </Center>

          <Checkbox
            label="信任此裝置 30 天(不建議在公用電腦上使用)"
            checked={trustDevice}
            onChange={(e) => setTrustDevice(e.currentTarget.checked)}
          />

          <Button
            onClick={handleVerify}
            loading={loading}
            disabled={code.length < (showBackupCode ? 8 : 6)}
            fullWidth
          >
            驗證
          </Button>

          <Group position="center">
            <Anchor
              size="sm"
              onClick={() => setShowBackupCode(!showBackupCode)}
            >
              {showBackupCode ? '使用驗證應用程式' : '使用備份碼'}
            </Anchor>
          </Group>

          {showBackupCode && (
            <Alert icon={<IconAlertCircle size={16} />} color="yellow">
              每個備份碼只能使用一次。使用後請立即重新生成備份碼。
            </Alert>
          )}
        </Stack>
      </Paper>
    </Container>
  );
}

帳號安全設定頁面

// src/pages/Security/SecuritySettings.tsx
import { useState, useEffect } from 'react';
import {
  Container,
  Paper,
  Title,
  Text,
  Stack,
  Group,
  Button,
  Badge,
  Switch,
  Timeline,
  Table,
  ActionIcon,
  Modal,
  Alert,
  Divider,
  ThemeIcon,
  Progress,
  Card,
  SimpleGrid,
} from '@mantine/core';
import {
  IconShield,
  IconDevices,
  IconHistory,
  IconKey,
  IconTrash,
  IconMapPin,
  IconClock,
  IconAlertTriangle,
  IconCheck,
  IconX,
} from '@tabler/icons-react';
import { useAuth } from '../../contexts/AuthContext';
import { notifications } from '@mantine/notifications';
import { formatDistanceToNow } from 'date-fns';
import { zhTW } from 'date-fns/locale';

interface SecurityDevice {
  id: string;
  name: string;
  deviceType: string;
  browser: string;
  os: string;
  ipAddress: string;
  location?: string;
  lastAccessAt: Date;
  isCurrent: boolean;
  trusted: boolean;
}

interface LoginHistory {
  id: string;
  timestamp: Date;
  ipAddress: string;
  location?: string;
  device: string;
  success: boolean;
  mfaUsed: boolean;
}

export function SecuritySettingsPage() {
  const { user } = useAuth();
  const [mfaEnabled, setMfaEnabled] = useState(false);
  const [devices, setDevices] = useState<SecurityDevice[]>([]);
  const [loginHistory, setLoginHistory] = useState<LoginHistory[]>([]);
  const [loading, setLoading] = useState(true);
  const [revokeModalOpen, setRevokeModalOpen] = useState(false);
  const [selectedDevice, setSelectedDevice] = useState<SecurityDevice | null>(null);

  useEffect(() => {
    fetchSecurityData();
  }, []);

  const fetchSecurityData = async () => {
    try {
      setLoading(true);

      const [mfaRes, devicesRes, historyRes] = await Promise.all([
        fetch('/api/auth/mfa/status', {
          headers: {
            'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
          },
        }),
        fetch('/api/auth/devices', {
          headers: {
            'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
          },
        }),
        fetch('/api/auth/login-history', {
          headers: {
            'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
          },
        }),
      ]);

      const mfaData = await mfaRes.json();
      const devicesData = await devicesRes.json();
      const historyData = await historyRes.json();

      setMfaEnabled(mfaData.enabled);
      setDevices(devicesData.devices);
      setLoginHistory(historyData.history);
    } catch (error) {
      console.error('Failed to fetch security data:', error);
    } finally {
      setLoading(false);
    }
  };

  const handleToggleMFA = async () => {
    if (mfaEnabled) {
      // 停用 MFA
      const confirmed = window.confirm('確定要停用雙因素驗證嗎?這會降低您的帳號安全性。');
      if (!confirmed) return;

      try {
        const response = await fetch('/api/auth/mfa/disable', {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
          },
        });

        if (response.ok) {
          setMfaEnabled(false);
          notifications.show({
            title: 'MFA 已停用',
            message: '雙因素驗證已關閉',
            color: 'yellow',
          });
        }
      } catch (error) {
        notifications.show({
          title: '操作失敗',
          message: '無法停用 MFA',
          color: 'red',
        });
      }
    } else {
      // 啟用 MFA - 導向設定頁面
      window.location.href = '/security/mfa-setup';
    }
  };

  const handleRevokeDevice = async (deviceId: string) => {
    try {
      const response = await fetch(`/api/auth/devices/${deviceId}`, {
        method: 'DELETE',
        headers: {
          'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
        },
      });

      if (response.ok) {
        setDevices(devices.filter(d => d.id !== deviceId));
        setRevokeModalOpen(false);
        notifications.show({
          title: '裝置已移除',
          message: '該裝置的存取權限已被撤銷',
          color: 'green',
        });
      }
    } catch (error) {
      notifications.show({
        title: '操作失敗',
        message: '無法撤銷裝置',
        color: 'red',
      });
    }
  };

  // 計算安全分數
  const calculateSecurityScore = (): number => {
    let score = 0;
    if (user?.emailVerified) score += 20;
    if (mfaEnabled) score += 40;
    if (devices.filter(d => d.trusted).length <= 3) score += 20;
    const recentLogins = loginHistory.filter(h =>
      h.timestamp > new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
    );
    if (recentLogins.length > 0 && recentLogins.every(h => h.success)) score += 20;
    return score;
  };

  const securityScore = calculateSecurityScore();

  const getScoreColor = (score: number): string => {
    if (score >= 80) return 'green';
    if (score >= 60) return 'yellow';
    return 'red';
  };

  return (
    <Container size="lg" my={40}>
      <Stack spacing="xl">
        <div>
          <Title order={2} mb="xs">
            帳號安全
          </Title>
          <Text color="dimmed">管理您的安全設定、裝置與登入歷史</Text>
        </div>

        {/* 安全分數 */}
        <Card withBorder padding="lg">
          <Group position="apart" mb="md">
            <div>
              <Text weight={500}>安全分數</Text>
              <Text size="sm" color="dimmed">
                您的帳號安全等級
              </Text>
            </div>
            <ThemeIcon size="xl" radius="xl" variant="light" color={getScoreColor(securityScore)}>
              <IconShield size={24} />
            </ThemeIcon>
          </Group>

          <Progress
            value={securityScore}
            color={getScoreColor(securityScore)}
            size="lg"
            mb="xs"
          />

          <Text size="sm" color="dimmed">
            {securityScore}/100 分
            {securityScore < 80 && ' - 建議啟用 MFA 以提升安全性'}
          </Text>
        </Card>

        {/* MFA 設定 */}
        <Paper withBorder p="lg">
          <Group position="apart" mb="md">
            <div>
              <Group spacing="xs" mb={4}>
                <Text weight={500}>雙因素驗證 (MFA)</Text>
                {mfaEnabled ? (
                  <Badge color="green" size="sm">已啟用</Badge>
                ) : (
                  <Badge color="red" size="sm">未啟用</Badge>
                )}
              </Group>
              <Text size="sm" color="dimmed">
                為您的帳號增加額外的安全層
              </Text>
            </div>
            <Switch
              checked={mfaEnabled}
              onChange={handleToggleMFA}
              size="lg"
            />
          </Group>

          {!mfaEnabled && (
            <Alert icon={<IconAlertTriangle size={16} />} color="orange">
              建議啟用 MFA 以保護您的帳號。即使密碼被盜,攻擊者也無法登入。
            </Alert>
          )}
        </Paper>

        {/* 信任的裝置 */}
        <Paper withBorder p="lg">
          <Group position="apart" mb="md">
            <div>
              <Text weight={500}>信任的裝置</Text>
              <Text size="sm" color="dimmed">
                管理您已登入的裝置
              </Text>
            </div>
            <Badge>{devices.length} 個裝置</Badge>
          </Group>

          <Stack spacing="md">
            {devices.map((device) => (
              <Card key={device.id} withBorder padding="md">
                <Group position="apart">
                  <div style={{ flex: 1 }}>
                    <Group spacing="xs" mb={4}>
                      <IconDevices size={18} />
                      <Text weight={500}>{device.browser} - {device.os}</Text>
                      {device.isCurrent && (
                        <Badge color="blue" size="sm">目前裝置</Badge>
                      )}
                      {device.trusted && (
                        <Badge color="green" size="sm">信任</Badge>
                      )}
                    </Group>
                    <Group spacing="lg">
                      <Group spacing={4}>
                        <IconMapPin size={14} />
                        <Text size="xs" color="dimmed">
                          {device.location || device.ipAddress}
                        </Text>
                      </Group>
                      <Group spacing={4}>
                        <IconClock size={14} />
                        <Text size="xs" color="dimmed">
                          {formatDistanceToNow(new Date(device.lastAccessAt), {
                            addSuffix: true,
                            locale: zhTW,
                          })}
                        </Text>
                      </Group>
                    </Group>
                  </div>
                  {!device.isCurrent && (
                    <ActionIcon
                      color="red"
                      variant="subtle"
                      onClick={() => {
                        setSelectedDevice(device);
                        setRevokeModalOpen(true);
                      }}
                    >
                      <IconTrash size={18} />
                    </ActionIcon>
                  )}
                </Group>
              </Card>
            ))}
          </Stack>
        </Paper>

        {/* 登入歷史 */}
        <Paper withBorder p="lg">
          <Text weight={500} mb="md">
            登入歷史
          </Text>

          <Timeline active={-1} bulletSize={24} lineWidth={2}>
            {loginHistory.slice(0, 10).map((log) => (
              <Timeline.Item
                key={log.id}
                bullet={log.success ? <IconCheck size={12} /> : <IconX size={12} />}
                title={
                  <Group spacing="xs">
                    <Text size="sm">
                      {log.success ? '成功登入' : '登入失敗'}
                    </Text>
                    {log.mfaUsed && (
                      <Badge size="xs" color="blue">MFA</Badge>
                    )}
                  </Group>
                }
              >
                <Text size="xs" color="dimmed">
                  {log.device}
                </Text>
                <Text size="xs" color="dimmed">
                  {log.location || log.ipAddress} · {' '}
                  {formatDistanceToNow(new Date(log.timestamp), {
                    addSuffix: true,
                    locale: zhTW,
                  })}
                </Text>
              </Timeline.Item>
            ))}
          </Timeline>
        </Paper>

        {/* 其他安全選項 */}
        <Paper withBorder p="lg">
          <Text weight={500} mb="md">
            其他安全選項
          </Text>

          <Stack spacing="md">
            <Group position="apart">
              <div>
                <Text size="sm" weight={500}>變更密碼</Text>
                <Text size="xs" color="dimmed">定期更新您的密碼</Text>
              </div>
              <Button variant="light" size="xs">
                變更
              </Button>
            </Group>

            <Divider />

            <Group position="apart">
              <div>
                <Text size="sm" weight={500}>登出所有裝置</Text>
                <Text size="xs" color="dimmed">撤銷所有裝置的存取權限</Text>
              </div>
              <Button variant="light" color="red" size="xs">
                登出全部
              </Button>
            </Group>

            <Divider />

            <Group position="apart">
              <div>
                <Text size="sm" weight={500}>下載個人資料</Text>
                <Text size="xs" color="dimmed">匯出您的所有資料</Text>
              </div>
              <Button variant="light" size="xs">
                下載
              </Button>
            </Group>
          </Stack>
        </Paper>
      </Stack>

      {/* 撤銷裝置確認 Modal */}
      <Modal
        opened={revokeModalOpen}
        onClose={() => setRevokeModalOpen(false)}
        title="撤銷裝置存取"
      >
        <Stack spacing="md">
          <Alert icon={<IconAlertTriangle size={16} />} color="orange">
            此操作將登出該裝置,您需要重新登入才能繼續使用。
          </Alert>

          {selectedDevice && (
            <div>
              <Text size="sm" weight={500}>裝置資訊:</Text>
              <Text size="sm" color="dimmed">
                {selectedDevice.browser} - {selectedDevice.os}
              </Text>
              <Text size="sm" color="dimmed">
                {selectedDevice.location || selectedDevice.ipAddress}
              </Text>
            </div>
          )}

          <Group position="right">
            <Button variant="subtle" onClick={() => setRevokeModalOpen(false)}>
              取消
            </Button>
            <Button
              color="red"
              onClick={() => selectedDevice && handleRevokeDevice(selectedDevice.id)}
            >
              確認撤銷
            </Button>
          </Group>
        </Stack>
      </Modal>
    </Container>
  );
}

裝置指紋識別

// src/utils/device-fingerprint.ts
import FingerprintJS from '@fingerprintjs/fingerprintjs';

/**
 * 裝置指紋服務
 *
 * 用途:
 * - 識別用戶裝置
 * - 檢測異常登入
 * - 實現「記住此裝置」功能
 */
export class DeviceFingerprintService {
  private static instance: DeviceFingerprintService;
  private fpPromise: Promise<any>;

  private constructor() {
    // 初始化 FingerprintJS
    this.fpPromise = FingerprintJS.load();
  }

  static getInstance(): DeviceFingerprintService {
    if (!DeviceFingerprintService.instance) {
      DeviceFingerprintService.instance = new DeviceFingerprintService();
    }
    return DeviceFingerprintService.instance;
  }

  /**
   * 取得裝置指紋
   */
  async getFingerprint(): Promise<string> {
    const fp = await this.fpPromise;
    const result = await fp.get();
    return result.visitorId;
  }

  /**
   * 取得裝置資訊
   */
  getDeviceInfo(): {
    browser: string;
    os: string;
    device: string;
    screenResolution: string;
    timezone: string;
    language: string;
  } {
    const userAgent = navigator.userAgent;
    const platform = navigator.platform;

    // 簡化的瀏覽器檢測
    let browser = 'Unknown';
    if (userAgent.includes('Firefox')) browser = 'Firefox';
    else if (userAgent.includes('Chrome')) browser = 'Chrome';
    else if (userAgent.includes('Safari')) browser = 'Safari';
    else if (userAgent.includes('Edge')) browser = 'Edge';

    // 簡化的 OS 檢測
    let os = 'Unknown';
    if (platform.includes('Win')) os = 'Windows';
    else if (platform.includes('Mac')) os = 'macOS';
    else if (platform.includes('Linux')) os = 'Linux';
    else if (/Android/.test(userAgent)) os = 'Android';
    else if (/iPhone|iPad/.test(userAgent)) os = 'iOS';

    // 裝置類型
    const isMobile = /Mobi|Android/i.test(userAgent);
    const isTablet = /Tablet|iPad/i.test(userAgent);
    let device = 'Desktop';
    if (isTablet) device = 'Tablet';
    else if (isMobile) device = 'Mobile';

    return {
      browser,
      os,
      device,
      screenResolution: `${window.screen.width}x${window.screen.height}`,
      timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
      language: navigator.language,
    };
  }

  /**
   * 檢查是否為信任的裝置
   */
  async isTrustedDevice(): Promise<boolean> {
    const fingerprint = await this.getFingerprint();
    const trustedDevices = JSON.parse(
      localStorage.getItem('trustedDevices') || '[]'
    );
    return trustedDevices.includes(fingerprint);
  }

  /**
   * 將裝置標記為信任
   */
  async trustDevice(): Promise<void> {
    const fingerprint = await this.getFingerprint();
    const trustedDevices = JSON.parse(
      localStorage.getItem('trustedDevices') || '[]'
    );

    if (!trustedDevices.includes(fingerprint)) {
      trustedDevices.push(fingerprint);
      localStorage.setItem('trustedDevices', JSON.stringify(trustedDevices));
    }
  }

  /**
   * 移除裝置信任
   */
  async untrustDevice(): Promise<void> {
    const fingerprint = await this.getFingerprint();
    const trustedDevices = JSON.parse(
      localStorage.getItem('trustedDevices') || '[]'
    );

    const filtered = trustedDevices.filter((id: string) => id !== fingerprint);
    localStorage.setItem('trustedDevices', JSON.stringify(filtered));
  }
}

// 使用範例
export const deviceFingerprint = DeviceFingerprintService.getInstance();

今日總結

我們今天完成了 Kyo System 的企業級帳號安全系統:

核心功能

  1. TOTP MFA: 完整的 QR Code 設定流程
  2. 備份碼: 加密儲存與恢復機制
  3. 裝置管理: 信任裝置追蹤與撤銷
  4. 登入歷史: 完整的審計追蹤
  5. 安全分數: 視覺化的安全等級指示
  6. 裝置指紋: 異常登入偵測
  7. 用戶體驗: 平衡安全性與便利性

技術比較

TOTP vs SMS OTP:

  • TOTP: 離線運作、免費、更安全
  • SMS: 需網路、有成本、易被攔截
  • 💡 建議:TOTP 為主,SMS 為備選

裝置信任機制:

  • 基於裝置指紋識別
  • 30 天免 MFA 登入
  • 可隨時撤銷
  • 💡 公用電腦不要啟用

安全分數計算:

  • Email 驗證: 20 分
  • MFA 啟用: 40 分
  • 裝置管理良好: 20 分
  • 無可疑登入: 20 分
  • 💡 視覺化提升用戶安全意識

備份碼最佳實踐:

  • 8-12 個備份碼
  • 每個只能用一次
  • 加密儲存在資料庫
  • 建議用戶下載並列印
  • 💡 使用後立即重新生成

帳號安全檢查清單

  • ✅ TOTP MFA 設定流程
  • ✅ QR Code 生成與掃描
  • ✅ 備份碼生成與管理
  • ✅ 裝置信任管理
  • ✅ 登入歷史展示
  • ✅ 安全分數計算
  • ✅ 裝置指紋識別
  • ✅ 異常登入偵測
  • ✅ 會話管理
  • ✅ 用戶體驗優化

上一篇
Day 25: 30天打造SaaS產品前端篇-用戶認證系統前端實作
系列文
30 天製作工作室 SaaS 產品 (前端篇)26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言